web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, addTags, removeTags, suggestTags, toggleGem, imageDownloadUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 let tagInput = $state('');
13 let suggestions = $state<string[]>([]);
14 let showSuggestions = $state(false);
15 let sugIndex = $state(-1);
16 let tagError = $state('');
17
18 onMount(async () => {
19 try {
20 img = await getImage(id);
21 } catch (err) {
22 error = err instanceof Error ? err.message : 'Failed to load image';
23 } finally {
24 loading = false;
25 }
26 });
27
28 function formatSize(bytes: number): string {
29 if (bytes < 1024) return bytes + ' B';
30 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
31 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
32 }
33
34 function formatDate(s: string): string {
35 return new Date(s).toLocaleString();
36 }
37
38 async function onTagInput() {
39 sugIndex = -1;
40 const q = tagInput.split(',').pop()?.trim() || '';
41 if (q.length < 1) {
42 suggestions = [];
43 showSuggestions = false;
44 return;
45 }
46 try {
47 const result = await suggestTags(q);
48 suggestions = result.map((t) => t.tag);
49 showSuggestions = suggestions.length > 0;
50 } catch {
51 suggestions = [];
52 showSuggestions = false;
53 }
54 }
55
56 function selectSuggestion(s: string) {
57 const parts = tagInput.split(',').slice(0, -1);
58 parts.push(s);
59 tagInput = parts.join(', ') + ', ';
60 showSuggestions = false;
61 sugIndex = -1;
62 }
63
64 async function handleAddTags() {
65 if (!img) return;
66 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
67 if (tags.length === 0) return;
68 tagError = '';
69 try {
70 const result = await addTags(img.id, tags);
71 img.tags_detail = result.tags_detail;
72 img.tags = result.tags_detail.map((t) => t.tag);
73 tagInput = '';
74 } catch (err) {
75 tagError = err instanceof Error ? err.message : 'Failed to add tags';
76 }
77 }
78
79 async function handleRemoveTags() {
80 if (!img) return;
81 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
82 if (tags.length === 0) return;
83 tagError = '';
84 try {
85 const result = await removeTags(img.id, tags);
86 img.tags_detail = result.tags_detail;
87 img.tags = result.tags_detail.map((t) => t.tag);
88 tagInput = '';
89 } catch (err) {
90 tagError = err instanceof Error ? err.message : 'Failed to remove tags';
91 }
92 }
93
94 // Auto-scroll active suggestion into view.
95 $effect(() => {
96 if (sugIndex >= 0) {
97 const el = document.querySelector('.sug-btn.active') as HTMLElement | null;
98 if (el) el.scrollIntoView({ block: 'nearest' });
99 }
100 });
101</script>
102
103{#if loading}
104 <p class="state-msg">Loading...</p>
105{:else if error}
106 <p class="state-msg error">{error}</p>
107{:else if img}
108 <div class="page-head">
109 <h1>{img.filename}</h1>
110 <div class="head-line"></div>
111 </div>
112
113 <div class="layout">
114 <div class="preview">
115 {#if img.thumbnail_path}
116 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
117 {:else}
118 <div class="no-thumb">NO THUMBNAIL</div>
119 {/if}
120 </div>
121
122 <div class="meta">
123 <div class="meta-block uploader-block">
124 <span class="meta-label">UPLOADED BY</span>
125 <div class="uploader-row">
126 {#if img.avatar_url}
127 <img src={img.avatar_url} alt="" class="avatar-img" />
128 {/if}
129 <a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
130 </div>
131 </div>
132 <div class="meta-block">
133 <span class="meta-label">ID</span>
134 <span class="meta-value">#{img.id}</span>
135 </div>
136 <div class="meta-block">
137 <span class="meta-label">DIMENSIONS</span>
138 <span class="meta-value">{img.width} × {img.height}</span>
139 </div>
140 <div class="meta-block">
141 <span class="meta-label">FILE SIZE</span>
142 <span class="meta-value">{formatSize(img.file_size)}</span>
143 </div>
144 <div class="meta-block">
145 <span class="meta-label">TYPE</span>
146 <span class="meta-value">{img.mime_type}</span>
147 </div>
148 <div class="meta-block">
149 <span class="meta-label">UPLOADED</span>
150 <span class="meta-value">{formatDate(img.created_at)}</span>
151 </div>
152 <div class="meta-block tags-block">
153 <span class="meta-label">TAGS</span>
154 <div class="tag-pills">
155 {#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
156 {#each img.tags_detail as t (t.tag)}
157 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
158 <span class="tag-name">{t.tag}</span>
159 <span class="tag-count">{t.usage_count}</span>
160 </a>
161 {/each}
162 {#if img.innate_tags}
163 {#each img.innate_tags as t (t)}
164 <a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
165 <span class="tag-name">{t}</span>
166 </a>
167 {/each}
168 {/if}
169 {:else}
170 <span class="none">none</span>
171 {/if}
172 </div>
173 </div>
174
175 {#if img.is_owner}
176 <div class="meta-block edit-tags-block">
177 <span class="meta-label">EDIT TAGS</span>
178 <div class="tag-input-row">
179 <div class="tag-wrapper">
180 <input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
181 oninput={onTagInput}
182 onkeydown={(e) => {
183 if (e.key === 'Enter') {
184 if (sugIndex >= 0 && sugIndex < suggestions.length) {
185 e.preventDefault();
186 selectSuggestion(suggestions[sugIndex]);
187 }
188 } else if (e.key === 'Tab' || e.key === 'ArrowDown') {
189 if (showSuggestions && suggestions.length > 0) {
190 e.preventDefault();
191 sugIndex = (sugIndex + 1) % suggestions.length;
192 }
193 } else if (e.key === 'ArrowUp') {
194 if (showSuggestions && suggestions.length > 0) {
195 e.preventDefault();
196 sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1;
197 }
198 } else if (e.key === 'Escape') {
199 showSuggestions = false;
200 sugIndex = -1;
201 }
202 }}
203 onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)}
204 />
205 {#if showSuggestions}
206 <div class="suggestions">
207 {#each suggestions as s, i}
208 <button type="button" class="sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button>
209 {/each}
210 </div>
211 {/if}
212 </div>
213 <button class="tag-btn add" onclick={handleAddTags}>ADD</button>
214 <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
215 </div>
216 {#if tagError}
217 <span class="tag-err">{tagError}</span>
218 {/if}
219 </div>
220 {/if}
221
222 <div class="meta-actions">
223 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
224 DOWNLOAD ORIGINAL
225 </a>
226 <button class="gem-btn" class:gemmed={img.gemmed} onclick={async () => {
227 try {
228 const r = await toggleGem(img.id);
229 img.gemmed = r.gemmed;
230 img.gem_count = r.gem_count;
231 } catch (err) {
232 console.error('gem toggle failed:', err);
233 }
234 }} aria-label="Toggle gem">
235 <svg class="gem-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
236 <polygon points="6,3 18,3 22,9 12,21 2,9" stroke-linejoin="round"/>
237 </svg>
238 <span class="gem-count">{img.gem_count || 0}</span>
239 </button>
240 </div>
241 </div>
242 </div>
243
244 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
245{/if}
246
247<style>
248 .state-msg {
249 padding: 2rem 0;
250 font-size: 1.1rem;
251 color: #888;
252 }
253 .error {
254 color: #c62828;
255 }
256
257 .page-head {
258 margin-bottom: 1.5rem;
259 }
260 .page-head h1 {
261 word-break: break-all;
262 }
263 .head-line {
264 height: 3px;
265 background: #c62828;
266 width: 3rem;
267 margin-top: 0.4rem;
268 }
269
270 .layout {
271 display: flex;
272 gap: 2rem;
273 flex-wrap: wrap;
274 }
275 .preview {
276 flex: 1;
277 min-width: 300px;
278 background: #1a1a1a;
279 display: flex;
280 align-items: center;
281 justify-content: center;
282 }
283 .preview img {
284 display: block;
285 width: 100%;
286 height: auto;
287 max-height: 70vh;
288 object-fit: contain;
289 }
290 .no-thumb {
291 padding: 4rem 2rem;
292 color: #555;
293 font-family: 'Oswald', sans-serif;
294 font-weight: 500;
295 font-size: 0.9rem;
296 letter-spacing: 0.12em;
297 }
298
299 .meta {
300 width: 260px;
301 flex-shrink: 0;
302 display: flex;
303 flex-direction: column;
304 gap: 1rem;
305 }
306 .meta-block {
307 display: flex;
308 flex-direction: column;
309 gap: 0.15rem;
310 }
311 .meta-label {
312 font-family: 'Oswald', sans-serif;
313 font-weight: 500;
314 font-size: 0.7rem;
315 letter-spacing: 0.15em;
316 color: #999;
317 }
318 .meta-value {
319 font-family: 'Inter', sans-serif;
320 font-size: 0.9rem;
321 font-weight: 600;
322 color: #1a1a1a;
323 }
324
325 .uploader-row {
326 display: flex;
327 align-items: center;
328 gap: 0.4rem;
329 }
330 .uploader-link {
331 color: #1a1a1a;
332 text-decoration: none;
333 }
334 .uploader-link:hover {
335 color: #c62828;
336 text-decoration: underline;
337 }
338
339 .avatar-img {
340 width: 1.5rem;
341 height: 1.5rem;
342 object-fit: cover;
343 flex-shrink: 0;
344 }
345
346 .tags-block {
347 flex: 1;
348 }
349 .tag-pills {
350 display: flex;
351 flex-wrap: wrap;
352 gap: 0.35rem;
353 }
354 .tag-pill {
355 display: inline-flex;
356 align-items: center;
357 gap: 0.3rem;
358 background: #1a1a1a;
359 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
360 font-family: 'Inter', sans-serif;
361 font-size: 0.78rem;
362 font-weight: 500;
363 color: #eee;
364 text-decoration: none;
365 transition: background 0.15s;
366 }
367 .tag-pill:hover {
368 background: #c62828;
369 text-decoration: none;
370 }
371 .tag-name {
372 font-weight: 600;
373 }
374 .tag-count {
375 background: rgba(255, 255, 255, 0.12);
376 padding: 0.05rem 0.35rem;
377 font-size: 0.68rem;
378 font-weight: 600;
379 color: rgba(255, 255, 255, 0.65);
380 min-width: 1.1rem;
381 text-align: center;
382 }
383 .tag-pill:hover .tag-count {
384 background: rgba(0, 0, 0, 0.2);
385 }
386 .tag-pill.innate {
387 background: #1565c0;
388 }
389 .tag-pill.innate:hover {
390 background: #1976d2;
391 text-decoration: none;
392 }
393 .tag-pill.innate .tag-count {
394 display: none;
395 }
396 .none {
397 color: #bbb;
398 font-size: 0.85rem;
399 }
400
401 .btn {
402 display: inline-flex;
403 align-items: center;
404 align-self: flex-start;
405 padding: 0.55rem 1.25rem;
406 background: #c62828;
407 color: #fff;
408 border: none;
409 font-family: 'Oswald', sans-serif;
410 font-weight: 500;
411 font-size: 0.8rem;
412 letter-spacing: 0.1em;
413 text-decoration: none;
414 text-transform: uppercase;
415 cursor: pointer;
416 transition: background 0.15s;
417 margin-top: 0.5rem;
418 }
419 .btn:hover {
420 background: #b71c1c;
421 text-decoration: none;
422 }
423 .meta-actions {
424 display: flex;
425 align-items: center;
426 gap: 0.5rem;
427 margin-top: 0.5rem;
428 }
429 .gem-btn {
430 display: flex;
431 align-items: center;
432 gap: 0.25rem;
433 background: none;
434 border: 2px solid #1a1a1a;
435 color: #1a1a1a;
436 padding: 0.45rem 0.6rem 0.45rem 0.5rem;
437 cursor: pointer;
438 transition: border-color 0.15s, color 0.15s, background 0.15s;
439 font: inherit;
440 line-height: 1;
441 }
442 .gem-btn:hover {
443 border-color: #c62828;
444 color: #c62828;
445 }
446 .gem-btn.gemmed {
447 border-color: #c62828;
448 color: #c62828;
449 background: rgba(198,40,40,0.08);
450 }
451 .gem-btn.gemmed:hover {
452 background: rgba(198,40,40,0.15);
453 }
454 .gem-icon {
455 display: block;
456 }
457 .gem-count {
458 font-family: 'Oswald', sans-serif;
459 font-size: 0.85rem;
460 font-weight: 600;
461 min-width: 1rem;
462 text-align: center;
463 }
464
465 .back {
466 margin-top: 2rem;
467 }
468 .back a {
469 font-family: 'Oswald', sans-serif;
470 font-weight: 500;
471 font-size: 0.85rem;
472 letter-spacing: 0.1em;
473 }
474
475 .edit-tags-block {
476 border-top: 2px solid #1a1a1a;
477 padding-top: 0.75rem;
478 }
479 .tag-input-row {
480 display: flex;
481 gap: 0.35rem;
482 align-items: flex-start;
483 }
484 .tag-wrapper {
485 flex: 1;
486 position: relative;
487 min-width: 0;
488 }
489 .tag-wrapper input[type="text"] {
490 width: 100%;
491 padding: 0.4rem 0.5rem;
492 border: 2px solid #1a1a1a;
493 border-radius: 0;
494 font-family: 'Inter', sans-serif;
495 font-size: 0.78rem;
496 font-weight: 500;
497 background: #fff;
498 box-sizing: border-box;
499 }
500 .tag-wrapper input[type="text"]:focus {
501 outline: none;
502 border-color: #c62828;
503 }
504 .suggestions {
505 position: absolute;
506 top: 100%;
507 left: 0;
508 right: 0;
509 background: #fff;
510 border: 2px solid #1a1a1a;
511 border-top: none;
512 margin: 0;
513 padding: 0;
514 z-index: 10;
515 max-height: 130px;
516 overflow-y: auto;
517 }
518 .sug-btn {
519 display: block;
520 width: 100%;
521 text-align: left;
522 padding: 0.35rem 0.5rem;
523 border: none;
524 background: none;
525 font-family: 'Inter', sans-serif;
526 font-size: 0.78rem;
527 font-weight: 500;
528 cursor: pointer;
529 border-radius: 0;
530 }
531 .sug-btn:hover {
532 background: #f0f0f0;
533 }
534 .sug-btn.active {
535 background: #c62828;
536 color: #fff;
537 }
538 .tag-btn {
539 padding: 0.4rem 0.65rem;
540 border: none;
541 font-family: 'Oswald', sans-serif;
542 font-weight: 500;
543 font-size: 0.72rem;
544 letter-spacing: 0.08em;
545 cursor: pointer;
546 white-space: nowrap;
547 transition: background 0.15s;
548 }
549 .tag-btn.add {
550 background: #1a1a1a;
551 color: #fff;
552 }
553 .tag-btn.add:hover {
554 background: #2e7d32;
555 }
556 .tag-btn.remove {
557 background: #1a1a1a;
558 color: #fff;
559 }
560 .tag-btn.remove:hover {
561 background: #c62828;
562 }
563 .tag-err {
564 display: block;
565 margin-top: 0.3rem;
566 color: #c62828;
567 font-family: 'Inter', sans-serif;
568 font-size: 0.72rem;
569 font-weight: 600;
570 }
571</style>